fix(create-objectstack): install the skills bundle for one agent so a scaffolded project commits it once - #16401
Conversation
… scaffolded project commits it once `skills add … --all` is shorthand for `--skill '*' --agent '*' -y`, and the `--agent '*'` half wrote the same 11-skill catalog to three destinations: `.agents/` (46 real files), `agent/` (46 real files, identical bodies) and `.claude/` (11 symlinks into `.agents/`). The template `.gitignore` excluded none of it, so a new project's first `git add -A` staged 22 `SKILL.md` paths plus 11 symlinks — measured against skills@1.5.23. The command now names one agent: `--skill '*' --agent claude-code -y`, which writes 46 real files to `.claude/skills/` and nothing else. Staged once, no symlinks, and a clone of that commit reads on every platform. Route A (keep `--all`, deny-list the duplicates) was built and cloned rather than reasoned about: ignoring `.agents/` leaves a cloner 11 dangling symlinks and zero readable SKILL.md; ignoring only `agent/` works on POSIX but commits symlinks that a `core.symlinks=false` clone materialises as plain files; and `--all --copy` fans out to 56 directories totalling 33.8 MB. A deny-list is also the wrong shape — the destination set belongs to the skills CLI and moves with its releases. The command and the printed hint now come from one builder in `src/skills-install.ts`, and the closing summary always prints an AI Skills block naming where the bundle landed plus the one-liner for other runtimes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
…per-agent install form `templates/AGENTS.md` and the blank template README both told a project's future readers to run the bare `npx skills add objectstack-ai/objectstack/skills`, which prompts for agents interactively — picking "all" there re-creates the triplicate this card removes, inside the user's own repository. Both now name the single-agent form, say where the committed copy lives, and refuse `--all` with the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
📓 Docs Drift Check10 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3c47b6a6f495bd9262f8bd6ca26283f4dc372547 && git checkout 3c47b6a6f495bd9262f8bd6ca26283f4dc372547
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3890244554ce94651dfdd7c1cb9fc1e1c19377ab 604dc4d28785917f18d7db7aefd9da1602f5b451 && git checkout -B drift-repro 3890244554ce94651dfdd7c1cb9fc1e1c19377ab && git merge --no-ff 604dc4d28785917f18d7db7aefd9da1602f5b451
node scripts/docs-audit/affected-docs.mjs --json 3890244554ce94651dfdd7c1cb9fc1e1c19377ab |
Fixes #16331
Route B, chosen on measurement
The card offered two routes and made the choice an in-card decision. Route B — install for one agent runtime by default and print the one-liner for the others — was taken, and every alternative was built and cloned rather than argued about.
Measured against
skills@1.5.23and this repo's 11-skill catalog, each into an empty directory:--all(=--skill '*' --agent '*' -y).agents/46 real files 604,102 B ·agent/46 real files 602,682 B ·.claude/11 symlinks into.agents/--skill '*' --agent claude-code -y.claude/46 real files 604,102 B, and nothing else--skill '*' --agent universal -y.agents/46 real files 604,102 B, and nothing else--all --copyThen, with the template's
.gitignorein place,git init && git add -A && git commit, thengit cloneinto a clean directory:SKILL.md--all, nothing ignored (today)--all, ignore.agents/+agent/(Route A, naive)SKILL.md--all, ignoreagent/only (Route A, viable)core.symlinks=falseclone the 11 links become ordinary files whose whole content is../../.agents/skills/NAME--skill '*' --agent claude-code -y(this PR)Route A's naive shape is the trap the triage comment predicted, and it is reproduced above by actually cloning, not by reasoning. Route A's viable shape does satisfy the card's acceptance on POSIX, and it was rejected for two reasons: it commits symlinks that git-for-Windows' default checkout materialises as stray text files, and — the structural one — a
_gitignoredeny-list is the wrong shape regardless of which paths it names. This package does not choose the destination set; the skills CLI does, and it moves with that package's releases.created-summary.tsalready documents the same property for the same reason. An ignore list has to chase it silently, in the direction that re-commits duplicates. Naming our own destination is explicit composition instead of default magic.The cost is the multi-runtime default, and it is paid in the open (see the new closing block below).
Acceptance, verified end to end
A real scaffold with the real skills CLI (only the package manager was stubbed; an install is irrelevant to the skills bundle), then
git add -A, commit, andgit cloneinto a clean directory:No
.agents/, noagent/, nothing left untracked.What changed
src/skills-install.ts(new) — one source for the command. The scaffolder runs it and the closing summary prints the same builder's output with a different agent name; a drifted pair is how a project acquires the second copy. The header carries the measurement table above.src/index.ts— runsSKILLS_INSTALL_COMMAND; the closing summary now prints an AI Skills block on every path, not only the skipped one. A single-agent install leaves every other runtime uninstalled, and an absent capability has to say so with the remedy:src/scaffold-skills-single-copy.test.ts(new) — the acceptance assertion the card asked for, counting stagedSKILL.mdpaths. It runs the real CLI throughtsxunder a stubbed PATH; the skills CLI is stubbed from the measured destination map, and the stub refuses an argv it has no measured row for (exit 3) so a command nobody measured fails loudly instead of passing against an invented layout. A vacuity block replays the--allrow and requires the same assertions to fail.src/template-consistency.test.ts— the/skillssubpath boundary is unchanged and still enforced; the pin now asserts the value the scaffolder runs rather than the source text that happens to hold it, and a second case keeps a hand-written duplicate invocation from creeping back in.src/created-summary.ts,README.md,src/templates/AGENTS.md,src/templates/blank/README.md— prose. The two template files told a project's future readers to run the bare interactive form; picking "all" there re-creates the triplicate inside the user's own repository, so both now name the per-agent form and refuse--allwith the reason.create-objectstackminor — Route B changes what gets installed, per the card._gitignoreis deliberately unchanged: with one destination there is nothing to exclude, and a deny-list for directories we no longer create is dead text.Verification
pnpm --filter create-objectstack test— 16 files, 203 passed.typecheckclean, andtsc --listFilesconfirms both new files are in the program (atypecheckthat misses the test files is not coverage).HEADblob): revertingSKILLS_INSTALL_COMMANDto--allturns 9 of 14 cases red, with the defect's own signature — staged roots['.agents','agent'], 6 stagedSKILL.mdinstead of 3, 3 symlinks. Restore leg:git diff HEADempty, blob hash identical toHEAD.node scripts/pm/dispatch-gates.mjs --ranreports 55 derived families, 55 run, 0 NOT-MEASURED, 0 UNRUN — including the two template families this path set pulls in (check:template-manifests,check:template-version-sync) andcheck:published-files.pnpm lint— the full repo-wideeslint . --no-inline-config, exit 0 in 100s at604dc4d2. Not a narrowing.packages/cliis inturbo ls --affected(it declarescreate-objectstackas a dependency): closure built,--project unitgreen — 181 files, 2438 passed. The other six affected packages reach this diff only through the one published entry point,create-objectstack/created-summary, and that entry point is byte-identical across the diff (dist/created-summary.jsand.d.tssha256 unchanged when the file is reverted toorigin/mainand rebuilt).Three gate results needed a note rather than a re-run:
check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3) because the workspace closure was unbuilt — both green once it was built.check:type-check-debtthen OOM'd under the container's default--max-old-space-size=4096, which is tighter than the 6144 ceiling the gate pins for itself; green at its own ceiling.check-plugin-teardown-shape --self-testcould not reach its pinned fixture commit on a shallow checkout, and is green aftergit fetch origin 621a4876.Out of scope, filed
#16400 — five documentation surfaces still tell readers to install with
--all, and two of them describe what the scaffolder does, so they go stale with this PR.content/docs/**andskills/**are outside this card's file surface and pull in a different gate family (skills/**is governed). Following those instructions in a git repository reproduces the triplication independently of the scaffolder, so closing this card does not close that.Not touched, per the card: the CI workflow (#16330, landed as
4998efa7),packages/cli/src/commands/init.ts(#16350), the scaffolded-project SyntaxError (#16373), andcontent/docs/releases/**.Generated by Claude Code